查看原文
其他

Python GIL

Tonner Python爱好者社区 2019-04-07

作者:Tonner    Python爱好者社区专栏作者

简书专栏:https://www.jianshu.com/u/0812b416fab9


在这篇博文中,我们将介绍Python GIL, Threads, ProcessesAsyncIO


假设我们想要编写一个函数,该函数将数字作为参数并简单的倒计时,挺容易:

def count_down(n):    while n > 0:        n -= 1

让我们用一个大数字来调用这个函数并测量函数耗时:

from time import time before = time() count_down(100000000) after = time() print(after - before)

在我的机器上,需要5.62秒。现在,让我们调用它两次并测量耗时

from time import time before = time() count_down(100000000) count_down(100000000) after = time() print(after - before)

在我的机器上,需要11.58秒


它正常工作,但是我们的老板不满意,他希望执行的更快。我们如何让它更快?我们使用多线程使这两次函数调用并行运行。理论上,并行运行函数两次应该花费函数一次执行的时间,因为函数的调用是并行执行的。让我们通过在不同的线程中调用这个函数两次:

from threading import Threadfrom time import time before = time() threads = []for item in range(1, 3):    t = Thread(target=count_down, args=(100000000,))    t.start()    threads.append(t) [thread.join() for thread in threads] after = time() print(after - before)

我的机器大约需要11.2秒。等等,什么?它不仅没有之前的快,但实际上它甚至比不使用多线程更慢。使用Python2.7你会得到更糟糕的结果。在一个着名的软件开发人员David Beazly报告(inside the Python GIl)中提到:它甚至花费几乎两倍的时间


But, WHY?


请欢迎臭名昭著的GIL(Global Interpreter Lock). 这家伙应该为Python世界上所有的事情负责。GIL在同一时间只允许一个线程运行。在上面的例子中,当我们认为我们使用了两个线程,我们实际只是用了一个,因为GIL不允许我们这么做。这就是它无法运行得更快的原因。但是,为什么它甚至更慢?因为Python试图不停的切换线程让两个线程同时工作。GIL不允许这么做。然而,无用的切换会带来额外的开销。因此,最终结果甚至更慢。


But, WHY?


我们都知道多线程是个好东西,可以帮我们让程序运行更快。 为什么有GIL的存在呢?该死的,GIL就像行尸走肉中的Negan



好吧,事实证明GIL并不是一个恶棍。实际上,GIL是一个好东西。实际上Python的内存管理不是线程安全的!也就是说,如果你同时运行多个线程,你的程序会出现各种莫名其妙的现象甚至是灾难性的。GIL可以帮我们摆脱这种情况。



但是,删除Thread类不是更容易吗?


很好的问题!既然想到要编写一个额外的工具来防止Threads对我们的程序产生不好影响,为什么不直接删除Threads类,让程序员根本无法使用它们呢?


实际上,有些情况Thread很有用。以上的示例都是CPU密集型计算,意味着它们只需在CPU上运行并且在CPU上等待。但是,如果你的代码是IO密集型计算或者是图像处理或者是NumPy数字运算,GIL将不会妨碍,因为这些操作发生在GIL之外。在这些地方你可以安全有效的使用Thread


让我们编写一个IO密集型计算的函数,函数完成请求一个url并将结果作为文本返回。

import requests def get_content(url):    response = requests.get(url)    return response.text

在我的机器上,使用参数https://google.com调用一次此函数需要1.1 秒,调用两次需要2.1秒。现在让我们在不同的线程中调用此函数两次,看看Thread 的效果。

urls = ['https://google.com'] * 2 before = time() threads = [] for url in urls:    thread1 = Thread(target=get_content, args=(url,))    thread1.start()    threads.append(thread1) [thread.join() for thread in threads] after = time() print(after - before)

只需要1.15秒,万岁!在不同的线程中调用两次相当于调用一次的时间。因为我们的函数是IO密集型计算。当我们的函数是CPU密集型计算(之前的示例中函数完成递减就是CPU密集型计算)时同样的事情就不会发生。


还有一个问题


每一个线程需要一些额外的内存,线程切换需要一些时间。虽然不是很多,但是当你运行数千个线程的时候资源的消耗就会增加。想象一下数千兆字节的额外RAM和至少5%的CPU时间仅用于上下文切换。


为了解决这个问题,Python开发人员提供了一个asyncio库。它有自己的事件循环来控制单个线程内异步方式的函数执行。如果线程被底层的操作系统控制,asyncio知道何时借助于开发人员自己编写的关键词切换任务。没错!你来决定何时切换。让我们将上面的示例转换成asyncio实现:

import asyncio import aiohttp loop = asyncio.get_event_loop() session = aiohttp.ClientSession(loop=loop) async def get_content(pid, url):    async with session.get(url) as response:        content = await response.read()        print(pid, content) loop.create_task(get_content(1, 'http://asyncio.readthedocs.io/')) loop.create_task(get_content(2, 'http://asyncio.readthedocs.io/')) loop.create_task(get_content(3, 'http://asyncio.readthedocs.io/')) loop.create_task(get_content(4, 'http://asyncio.readthedocs.io/')) loop.create_task(get_content(5, 'http://asyncio.readthedocs.io/')) loop.run_forever()

注意我们使用了aiohttp代替了requests ,因为它是一个异步的HTTP包。在不涉及细节的情况下,这段代码做所的是定义一个异步函数(aka协成)并使用不同的ID(仅仅是为了说明其异步性)调用五次,输出应该是这样的:

1 b'\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 3 b'\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 4 b'\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 5 b'\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 2 b'\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0

请注意,ID不是按照他们的调用顺序来显示的,也就是说当遇到关键词await时任务被切换了。我们可以使用Thread 得到相同的结果,但是asyncio使用了较少的开销并且您控制切换过程。


虽然这个示例没有解释asyncio的细节, 但我希望它至少能解释为什么它是有用的。


好了,我们现在处理了IO密集型任务,让我们回到我们第一个CPU密集型计算的问题。


CPU密集型计算任务的解决方式


Python中,multiprocessing.Process提供了和Thread相似的功能和接口。区别是它使用了子进程而不是线程。这就是它不会被GIL阻塞的原因。太棒了!让我们使用Process重写上面的示例。正如我所说,Process提供了和Thread形似的接口,我们只需要把Thread替换成Process即可完成这个示例:

from multiprocessing import Process from time import time def count_down(n):    while n > 0:        n -= 1 before = time() processes = [] for item in range(1, 2):    process1 = Process(target=count_down, args=(100000000,))    process1.start()    processes.append(process1) [process.join() for process in processes] after = time() print(after - before)

我的机器花了11秒。这是我们之前调用一次函数的时间。酷!试着再添加一个进程仍然需要同样的时间。太棒了!但是,请记住,进程在一个单独的内容空间运行,因此不能彼此共享数据,而线程可以。


我们这里谈的是Cpython。还有一些其他的实现,像JpythonIronPython没有GIL,所以可以直接使用线程。如果你不知道Jpython或者IronPython是什么,请自行GOOGLE,这里有一篇博文仅供参考:What is Python?

http://rahmonov.me/posts/what-is-python/


感谢阅读,如果有任何问题,请留下评论。


Fight on!

Python爱好者社区历史文章大合集

Python爱好者社区历史文章列表(每周append更新一次)

福利:文末扫码立刻关注公众号,“Python爱好者社区”,开始学习Python课程:

关注后在公众号内回复“课程”即可获取:

小编的Python入门免费视频课程!!!

【最新免费微课】小编的Python快速上手matplotlib可视化库!!!

崔老师爬虫实战案例免费学习视频。

陈老师数据分析报告制作免费学习视频。

玩转大数据分析!Spark2.X+Python 精华实战课程免费学习视频。


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存